iT邦幫忙

2024 iThome 鐵人賽

DAY 15
0
Software Development

Django 2024: 從入門到SaaS實戰系列 第 17

Django REST framework: 視圖的進化之旅 - GenericAPI 到 ViewSet,從通用基礎到高層抽象

  • 分享至 

  • xImage
  •  

我們在之前Django的章節沒有特別仔細聊過使用視圖類別,有幾個因素:

  1. 畢竟這個系列文章也是將Django從頭開始介紹,對於剛接觸Django的人來說,使用Functional Based View(FBV)會更加的容易理解
  2. 先前的需求較為單純,且業務邏輯都很分散,比較沒有需要封裝重複使用的部分,並且我們大部分都是GET與POST的請求,FBV已經能很好的達成任務
  3. 其實我們一直在使用其他的類別:Django Form以及Django Admin,我們大部分對於對象的需求都已經在此之上定義好了,因此視圖本身就不是主角

但是在API方面,我個人反而是比較喜歡使用Class Based View(CBV)大於FBV,有幾個原因:

  1. 簡潔度:當API的數量一增加,FBV的路由相比CBV需要管理的路由數量更多,個人更喜歡簡潔的路由,在命名上也相對容易
  2. OOP優勢(Object Oriented Programming):CBV比FBV的優勢在於繼承與對象的屬性,在開發上會更加方便,許多需要重複使用的變數或是函式,均可變成對象的屬性以及方法,再透過繼承省去許多開發時間
  3. 個人風格:因為我自己也有在用Django開發輕量的前端(搭配如HTMX與alpine.js相關工具),並且有時頁面所需的所有資料不一定那麼好進行封裝或是抽象,更習慣在一個FBV中調用多個FBV所產生的結果。但是通常在進行API開發時,通常是需要對特定的資源集進行CRUD等操作,我更偏好在這時候使用CBV,維持可讀性與開發速度

當然CBV的缺點就是開發會比較笨重,且過度封裝的代價就是debug與自定義邏輯有時候會作繭自縛,所以還是要根據個人的需求進行調整

今日重點如下:

  • 開始體會通用的美好:GenericAPIView
  • 讓通用更上一層樓:GenericAPIView 搭配 Mixin
  • 簡化常見模式:Generics.類別的威力
  • 統一與簡化:ViewSet 的整合之道
  • CBV的使用情境比較

今日的程式碼:https://github.com/class83108/drf_demo/tree/CBV

開始體會通用的美好:GenericAPIView

我們昨天使用APIView來初步體驗一下Django Rest Framework(DRF)的CBV開發形式,可能會覺得跟FBV也沒什麼區別啊?那就得提到GenericAPIView

首先我們看一下APIView的部分,在DetailView中,我們有一個通用的方法是先根據primary key來拿到模型實例,然後再根據請求方法進行對應的操作


class WorkspaceDetail(APIView):
    def get_object(self, pk):
        try:
            return Workspace.objects.get(pk=pk)
        except Workspace.DoesNotExist:
            raise Response(status=status.HTTP_404_NOT_FOUND)

    def get(self, request, pk):
        workspace = self.get_object(pk)
        serializer = WorkspaceSerializer(workspace)
        return Response(serializer.data)

    def put(self, request, pk):
        workspace = self.get_object(pk)
        serializer = WorkspaceSerializer(workspace, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    def delete(self, request, pk):
        workspace = self.get_object(pk)
        workspace.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

那我們接著來看GenericAPIView

class WorkspaceDetail(GenericAPIView):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceSerializer

    def get(self, request, pk):
        workspace = self.get_object()
        serializer = self.get_serializer(workspace)
        return Response(serializer.data)

    def put(self, request, pk):
        workspace = self.get_object()
        serializer = self.get_serializer(workspace, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    def delete(self, request, pk):
        workspace = self.get_object()
        workspace.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

看到直接定義好querysetserializer_class兩個屬性,能讓人快速知道這個CBV是要使用哪個序列化器來處理資料

這邊可能會有幾個疑問:

  1. 為什麼明明是DetailView還是使用queryset = Workspace.objects.all()
  2. 既然都定義了queryset,怎麼方法內部還是要調用self.get_object()
  3. GenericAPIView相比APIView只有改變這樣的話,好像我也可以稍微改寫一下就好?

1跟2需要一起回答,首先我們來看get_object的源碼

    def get_object(self):
        """
        Returns the object the view is displaying.

        You may want to override this if you need to provide non-standard
        queryset lookups.  Eg if objects are referenced using multiple
        keyword arguments in the url conf.
        """
        queryset = self.filter_queryset(self.get_queryset())

        # Perform the lookup filtering.
        lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field

        assert lookup_url_kwarg in self.kwargs, (
            'Expected view %s to be called with a URL keyword argument '
            'named "%s". Fix your URL conf, or set the `.lookup_field` '
            'attribute on the view correctly.' %
            (self.__class__.__name__, lookup_url_kwarg)
        )

        filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
        obj = get_object_or_404(queryset, **filter_kwargs)

        # May raise a permission denied
        self.check_object_permissions(self.request, obj)

        return obj
  • 首先self.get_queryset()拿到queryset屬性定義的查詢集,並且經過self.filter_queryset後拿到真正查詢時的queryset,self.filter_queryset可以透過以下方式進行定義,不建議直接改寫
# settings.py

REST_FRAMEWORK = {
    'DEFAULT_FILTER_BACKENDS': [
        # 你要進行的filter
        ...
    ]
}
  • 透過self.lookup_url_kwargself.lookup_field來找到路由中的參數名,通常是主鍵或是id
class GenericAPIView(views.APIView):
    lookup_field = 'pk'
    lookup_url_kwarg = None
  • 確定有參數的話,拿參數與queryset去拿到模型實例
  • 在官方文檔中也有提到,如果你不想要預設從all()開始篩選,可以改寫get_queryset方法,比較不建議修改queryset

這時我們就可以回答第一個與第二個問題,設定queryset屬性更像是我們告訴這個視圖類要去找哪一個模型查資料,而**self.get_object()**才是真正實現我們要怎麼查詢的最終方法

那我們說說使用GenericAPIView的好處,當我們不需要特別修改get_object()時,可以用不自己寫方法,但是還是保留了我們可以覆寫get_object()的自由度。並且GenericAPIView還能透過屬性方法來定義分頁與分類功能,這也是APIView所沒有的(這部分留到Day20再說明)

讓通用更上一層樓:GenericAPIView搭配Mixin

Mixin提供了更多有關基本視圖行為的操作,而不是直接定義get或是post方法

例如:

ListModelMixin:返回模型實例的列表響應

RetrieveModelMixin:返回單一模型實例的列表響應

並且也提供了perform_create()perform_update() 方法,讓我們在建立新物件或是更新時,有更靈活的操作

這邊我們就拿ListView來做示範(上一個示範是DetailView)

from rest_framework.mixins import (
    ListModelMixin,
    CreateModelMixin,
)

class WorkspaceList(GenericAPIView, ListModelMixin, CreateModelMixin):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceSerializer

    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

可以看到在get方法中,調用了list方法返回列表響應,而在post方法中,則使用create方法來建立物件

list方法我這邊不特別解釋,我們則可以來好好看一下CreateModelMixin的源碼中是如何定義create方法

class CreateModelMixin:
    """
    Create a model instance.
    """
    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        headers = self.get_success_headers(serializer.data)
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

    def perform_create(self, serializer):
        serializer.save()
  • 在create中將資料驗證過後,會調用perform_create方法,最後返回Response

也就是說perform_create方法,當你的資料都是要按照request.data中的資料完全不改的儲存,可以不用特別定義,反之我們可以在這方法中定義儲存時的業務邏輯

可以看到perform_create中我們將owner定義為self.request.user,因此我們將資料直接發出POST請求

{
    "name": "demo 10", "members": [2]
}

https://ithelp.ithome.com.tw/upload/images/20240927/20161866TaZ9PSjgI6.png

什麼!我們不是定義好owner是誰了啊?為什麼還要說我沒附上資料,我們回去看源碼

必須要先驗證過,才會進到perform_create,而我們的序列化器中,owner不是只讀屬性,所以還是得定義好

def create(self, request, *args, **kwargs):
    serializer = self.get_serializer(data=request.data)
    serializer.is_valid(raise_exception=True)
    self.perform_create(serializer)

也因此如果我們定義了perform_create,今天你設定哪一個owner,最後儲存的都還是你在perform_create所設定的,這點需要注意

但是如果有看Django REST framework: 序列化器與視圖函式 開啟API之旅的讀者應該對於剛剛的操作有違和感,沒錯!我們是使用字典而非列表來建立資料,這也是一個我覺得有點可惜的地方

如果想要更符合Restful風格一點的話,除了修改post方法中去判斷是否為list之外,我更偏好直接修改序列化器,讓我們的視圖類別更簡潔


class WorkspaceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Workspace
        fields = [
            "id",
            "name",
            "owner",
            "members",
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]

    def create(self, validated_data):
        if isinstance(validated_data, list):
            return Workspace.objects.bulk_create(validated_data)
        return super().create(validated_data)

使用GenericAPIView搭配Mixin的好處是,Mixin的方法命名更直觀一點,因為GET不會知道是列表還是單一對象,而有時候POST也不一定得創建資料,可能是想要驗證數據,因此搭配Mixin我們更能把通用的GenericAPIView打造成不失通用且靈活的作法

但是同時應該也注意到了,為了通用而做的封裝在我們不了解其中的原理時,在開發上可能會產生不如我們預期的結果

簡化常見模式:Generics類別的威力

雖然GenericAPIView搭配Mixin已經大幅減少我們的程式碼量,但是還是不免讓人覺得在get方法裡再調用list方法,在post中調用create方法好像沒有那麼直觀,這時候Generics就登場啦

我們可以直接看程式碼,非常簡潔並且容易理解

從ListCreateAPIView與RetrieveUpdateDestroyAPIView的名稱我們就能明白他的作用

from rest_framework import generics

class WorkspaceList(generics.ListCreateAPIView):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceSerializer

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

class WorkspaceDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceSerializer

    def perform_update(self, serializer):
        instance = serializer.save()
        if "members" in self.request.data:
            instance.members.set(self.request.data["members"])

想要看還有提供哪些方法可以看底下的參考資料

如果我們想用通用方法大幅減少我們程式碼量的同時,又想要微調一些特定的方法(通常是queryset),可以參考官方文檔的範例

建立自定義Mixin,並且自定義get_object使其能夠去找url中我們定義在self.lookup_fields的關鍵字參數

class MultipleFieldLookupMixin:
    """
    Apply this mixin to any view or viewset to get multiple field filtering
    based on a `lookup_fields` attribute, instead of the default single field filtering.
    """
    def get_object(self):
        queryset = self.get_queryset()             # Get the base queryset
        queryset = self.filter_queryset(queryset)  # Apply any filter backends
        filter = {}
        for field in self.lookup_fields:
            if self.kwargs.get(field): # Ignore empty fields.
                filter[field] = self.kwargs[field]
        obj = get_object_or_404(queryset, **filter)  # Lookup the object
        self.check_object_permissions(self.request, obj)
        return obj

在視圖類中進行繼承並且設置好屬性就能透過非常精簡的方式快速完成CRUD等相關需求

class RetrieveUserView(MultipleFieldLookupMixin, generics.RetrieveAPIView):
    queryset = User.objects.all()
    serializer_class = UserSerializer
    lookup_fields = ['account', 'username']

統一與簡化:ViewSet 的整合之道

在Generics我們已經非常大幅度的簡化程式碼,但是如果我們就只想要一個大而全的對象呢?可以完成對這個模型的所有操作?DRF中的ViewSet就可以幫助我們整合多個視圖類別達到最極致的簡化

ViewSet跟前面Generics比較不同的是ViewSet將常見的 CRUD 操作抽象化為一組標準方法(list, create, retrieve, update, destroy),而比較不是像Generics所進行的封裝:隱藏實現細節

那我們直接來看程式碼吧

from rest_framework import viewsets

class WorkspaceViewSet(viewsets.ModelViewSet):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceSerializer

ViewSet中有幾種視圖集:

  • ViewSet :需要在自己重寫方法,一般不太使用
  • ModelViewSet ModelViewSetModelViewSet 繼承自 GenericAPIView,並通過混合各種 Mixin 類的行為來包含各種操作的實現,如 list 、 retrieve 、create 等等,較常使用此視圖集
  • ReadOnlyModelViewSet:跟ModelViewSet相像,但是只提供list 與 retrieve

也因為現在不會顯示的寫接收的方法接口,因此在路由中我們需要寫DefaultRouter幫忙生成路由

# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
    WorkspaceViewSet,
)

router = DefaultRouter()
router.register(r"workspaces", WorkspaceViewSet)

urlpatterns = [
    ...
    path("", include(router.urls)),
]

那我們看一下要怎麼實際操作吧,我們這邊用postman示範,比較能控制請求方法

GET方法:取得列表資料

https://ithelp.ithome.com.tw/upload/images/20240927/20161866nBqAZU4BOo.png

GET方法:根據主鍵取得單一對象的資料

注意url中有附上id
https://ithelp.ithome.com.tw/upload/images/20240927/20161866gGIiJbVMPm.png

POST方法:建立新資料

https://ithelp.ithome.com.tw/upload/images/20240927/20161866RozO5ATcns.png

PUT方法:提供完整資料來更新單筆對象

APPEND_SLASH可以在settings.py中修改
https://ithelp.ithome.com.tw/upload/images/20240927/20161866nIxJHvZzzV.png

PATCH方法:提供部分資料來更新單筆對象

https://ithelp.ithome.com.tw/upload/images/20240927/20161866YeFgv896qD.png

那這時候會想:

  1. 如果我們想要限制這個路由可以使用的請求方法呢?
  2. 如果我們自定義方法的話路由要怎麽配置呢?

第一種很單純,就是設置對應的屬性就好

class WorkspaceViewSet(viewsets.ModelViewSet):
    # 限制只允許某些 HTTP 方法
    http_method_names = ["get"]
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceSerializer

https://ithelp.ithome.com.tw/upload/images/20240927/201618667TlgDShwZV.png

第二個問題的話,我們可以自訂一個方法是用來返回workerspace下所有Document的名稱

忘記我們模型配置的話:https://github.com/class83108/drf_demo/blob/CBV/drf_demo/note/models.py

class WorkspaceViewSet(viewsets.ModelViewSet):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceSerializer

    @action(detail=True, methods=["get"])
    def document_names(self, request, pk=None):
        workspace = self.get_object()
        document_names = workspace.documents.values_list("title", flat=True)
        return Response({"document_names": list(document_names)})

action裝飾器代表了將其標記成路由,並且有以下功能

  • 透過設置detail的True or False來判斷是要返回列表還是單一對象
  • methods則可以指定請求方法
  • 還能設置 permission_classesserializer_classfilter_backends 等來覆蓋視圖集的屬性

我們設置好了之後根據路由請求

https://ithelp.ithome.com.tw/upload/images/20240927/20161866RwwhpLXAhK.png

CBV的使用情境比較

CBV 類型 特點 適用場景 優點 缺點
APIView 需手動處理 HTTP 方法 1. 需要完全控制請求處理流程 2. 自定義複雜邏輯 1. 靈活性最高 2. 可以精確控制每個細節 需要編寫最多程式碼
GenericAPIView 提供通用功能如查詢集和序列化器 需要一些通用功能但仍需自定義邏輯 1. 減少重複代碼 2. 提供常用功能 1. 仍需手動實現 CRUD 方法 2. 對於簡單場景可能過於複雜
GenericAPIView + Mixins 1. 將通用操作分解為可重用的組件 2. 可以組合不同的 Mixins 1. 需要靈活組合不同 CRUD 操作 2. 自定義特定 CRUD 行為 高度可組合,可以精確控制包含哪些操作 1. 可能需要多重繼承 2. 對新手可能不夠直觀 3. 有Generics 類別與自定義Mixin後存在感很低
Generics 類別 1. 預先組合了常用的 GenericAPIView 和 Mixins 2. 提供開箱即用的 CRUD 操作 1. 標準的 CRUD 操作 2. 快速開發 RESTful API 1. 程式碼極其簡潔2. 快速實現標準 API 1. 靈活性較低 2. 自定義行為可能受限
ViewSet 1. 將一組相關視圖邏輯組合在一個類別中 2. 與路由器集成良好 1. 需要在一個類中處理資源的所有操作 2. 構建完整的 RESTful API 1. 程式碼更簡潔 2. 快速實現標準 API 3. 容易擴展 1. 可能包含不需要的方法 2. 對於簡單 API 可能過於重量級 3. 多了一層抽象,可讀性較差

使用建議:

  1. 從簡單到複雜:對於簡單的 API,可以從 Generics 類開始。隨著需求變得複雜或是需求較特化,可以逐步過渡回更基礎的類別
  2. 按需選擇:根據項目的具體需求選擇合適的 CBV。不必為了使用高級特性而過度設計
  3. 混合使用:在同一個項目中,可以根據不同端點的需求混合使用不同類型的 CBV
  4. 注意可讀性:使用更高級的 CBV 時,確保程式碼的可讀性和可維護性不受影響
  5. 考慮團隊熟悉度:選擇 CBV 時也要考慮團隊成員對不同類型 CBV 的熟悉程度,不要為了用而用

今日總結

我們今天對DRF中的CBV做了更完整與更深入的介紹,DRF中的CBV的確種類繁多並且不熟悉中間的實現原理,可能會造成使用上有很大的障礙。如果想要一開始就先體驗CBV的便捷,可以使用Generics 類別然後自定義自己的Mixin來做開發,既可以應付複雜需求,在基礎開箱即用的部分也有很高的覆蓋性

參考資料

  • GenericView:https://www.django-rest-framework.org/api-guide/generic-views/#genericapiview
  • Mixins:https://www.django-rest-framework.org/api-guide/generic-views/#mixins
  • Generic:https://www.django-rest-framework.org/api-guide/generic-views/#concrete-view-classes
  • ViewSet:https://www.django-rest-framework.org/api-guide/viewsets/#viewset-actions

上一篇
Django REST framework: 深入探討視圖類之前,不可不知道的序列化器原理
下一篇
Django REST framework: 序列化器的高級技巧與最佳實踐
系列文
Django 2024: 從入門到SaaS實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言